[!NOTE]
本系列文章已進行全域去敏與匿名化處理,專案代號統一為DataGateway,內網 IP 統一為10.0.0.x。
昨天我們討論了邊緣儲存中最可怕的「雙腦分裂 (Split-Brain)」危機。只要 Linux (Raspberry Pi) 和 Windows (PC) 同時掛載同一個 FAT32 虛擬磁碟,資料結構必定會崩潰。
為了解決這個問題,我們設計了「軟體互斥鎖 (Software Mutex)」策略:
但問題來了:在一個沒有螢幕、沒有鍵盤的無頭設備 (Headless Device) 上,Linux 怎麼知道「護理師現在把 USB 線插上去了」?
在 Raspberry Pi 的底層架構中,當 USB OTG 埠 (Type-C) 偵測到外部有 5V 電壓或 USB Host 的連線請求時,dwc2 驅動程式會在系統日誌與 sysfs 中產生狀態改變。
在 ConfigFS 的架構下,當 USB 線插上並且成功與 PC 交握 (Enumeration) 後,UDC 的狀態會從 not attached 轉變為 configured。
我們可以直接讀取 UDC 的 state 檔案來得知當前狀態:
cat /sys/class/udc/fe980000.usb/state
# 若未插線:not attached
# 若已插線並與 PC 連線:configured 或 suspended
有了這個基礎,我們就能在 Python 中寫一個無窮迴圈的監聽程式 (Monitor Thread)。
這支監聽程式的職責很單純:每一秒檢查一次 UDC 狀態,並根據狀態切換執行對應的 Mount/Unmount 腳本。
import os
import time
import subprocess
import threading
class UsbMonitor:
def __init__(self):
# 動態獲取 UDC 名稱 (沿用 Day 13 的技巧)
udc_dir = "/sys/class/udc"
try:
self.udc_name = os.listdir(udc_dir)[0]
self.state_file = f"{udc_dir}/{self.udc_name}/state"
except IndexError:
self.state_file = None
self.is_connected = False
self.monitor_thread = None
def read_usb_state(self):
if not self.state_file or not os.path.exists(self.state_file):
return "not attached"
with open(self.state_file, 'r') as f:
return f.read().strip()
def _monitor_loop(self):
while True:
current_state = self.read_usb_state()
# 判斷是否連線到 PC (configured 代表連線成功)
is_now_connected = current_state in ["configured", "suspended"]
if is_now_connected and not self.is_connected:
print("[USB] 偵測到纜線插入!交出控制權...")
self._handle_cable_plugged()
self.is_connected = True
elif not is_now_connected and self.is_connected:
print("[USB] 偵測到纜線拔除!奪回控制權...")
self._handle_cable_unplugged()
self.is_connected = False
time.sleep(1.0)
這就是整套機制中最驚險的時刻。
當 _handle_cable_plugged() 被呼叫時,Linux 必須立刻卸載 (umount) /mnt/usb_sync。
但是,如果此時 DataGateway 剛好正在寫入最新的心跳資料呢?
如果另一個 Thread 正在執行 open('/mnt/usb_sync/data.csv', 'w'),檔案被佔用 (Device is busy),標準的 umount 指令就會失敗報錯。
若卸載失敗,而 Windows 又強行接管,雙腦分裂就發生了!
為了解決這個「正在寫入時被拔插」的終極難題,我們必須祭出 Linux 的秘密武器:Lazy Unmount (umount -l)。
當你執行 umount -l /mnt/usb_sync 時,Linux 會做兩件事:
close() 後,才真正切斷實體 Loop Device 的聯繫。這簡直就是為我們的邊緣設備量身打造的救命仙丹!
def _handle_cable_plugged(self):
# 1. 確保所有快取刷入實體檔案
subprocess.run(['sync'])
# 2. 使用 Lazy Unmount,即使有檔案正在寫入也不會報錯
subprocess.run(['sudo', 'umount', '-l', '/mnt/usb_sync'])
# 3. 釋放 Loop Device
# 注意:如果有程序還沒寫完,losetup -d 會失敗,所以我們稍後會由排程器重試
subprocess.run(['sudo', 'losetup', '-d', '/dev/loop0'], check=False)
print("[USB] 磁碟已交由 Windows 接管")
def _handle_cable_unplugged(self):
# 1. 確保 Loop Device 乾淨
subprocess.run(['sudo', 'losetup', '-d', '/dev/loop0'], check=False)
# 2. 重新綁定並掛載
subprocess.run(['sudo', 'losetup', '/dev/loop0', '/opt/gateway/usb_disk.img'])
subprocess.run(['sudo', 'mount', '-t', 'vfat', '-o', 'uid=1000,gid=1000', '/dev/loop0', '/mnt/usb_sync'])
print("[USB] 磁碟已掛載回 Linux,準備寫入新資料")
[!WARNING]
Lazy Unmount 雖然強大,但它是一種「非同步」的操作。如果你在 Lazy Unmount 尚未完全結束前,就立刻嘗試重新掛載,可能會引發 Kernel Panic。因此,在插拔瞬間的防彈跳 (Debounce) 處理極為重要。
透過 UDC 狀態監聽與 Lazy Unmount 的結合,我們終於徹底解決了雙腦分裂的問題。
但是... 你以為這樣就結束了嗎?
第二天,護理站打電話來抱怨:「為什麼我明明按了 Windows 的『安全移除硬體』,拔下設備再插上去,最新的一筆資料卻不見了?」
明天,我們將面對 Windows 作業系統最狡猾的敵人:Windows 緩存的逆襲。